这篇文章中间磨洋工磨了比较久,整体写的也不是很好
主要是Uworld的更新涉及到太多的宏和函数跳转嵌套,给我整的麻麻的
但好在最后还是弄清楚了最主要的tick流程是在做什么
为接下来弄清楚RHI线程和Render线程做好铺垫
UGameEngine::tick
是FengineLoop中游戏线程的tick主体,实际上是UGameEngine::tick
其实这里困惑了我好久,因为我对着Gengine->Tick并不能直接找到其定义,到相关的cpp文件找也没找到
再加上ue源码的头文件和Cpp文件也不是一一对应的,一度怀疑自己眼花了还是cpp学的有问题
然后发现了是PURE_VIRTUAL这个宏搞的鬼,最后定位到了真正的tick函数的位置
(cpp文件中可以不用实现此函数,同时自己其他函数中又可以直接调用此函数,且子类需要强制实现此函数)
在FengineLoop的注释上对这个函数的解释是main game engine tick (world, game objects, etc.)
也印证game线程的主要tick都是在这个函数里面完成的
主要的学习方式还是按照一些前辈整合的一些流程先看看有个印象,然后再去源码里面挨个验证思想
总体流程
engine的tick分为以下几个阶段:
TickAsyncLoading:
也被称为StaticTick阶段,主要是做一些异步资源的更新
WorldTick:
遍历WorldList,逐World的做tick,也是我们这阶段tick的主体
里面其实还包含了对每个world都做一遍TickWorldTravel处理关卡加载逻辑
Tickable GameObjects Without World:
引擎会让一些对象继承FTickableGameObject来获得tick的功能
我们这里tick的是不在世界内的tick,世界内Tickable的tick在Utick里面做
RedrawViewPort:
注释写的是Render everything,意思就是说这个draw操作就渲染了所有所需的元素
但写成Redraw让我怀疑这一次Loop里面在这之前也有draw的操作
后边想想这个Redraw可能是对应上一帧的,所以说Redraw也没错
渲染的流程走完以后还会进行一些后处理,例如PostRenderAllViewports做一些渲染完场景才能做的任务
然后还会给渲染队列塞个TickRenderingTimer来更新RT池
TickAsyncLoading
这里应该是涉及到UE4运行时期间加载资源的方法,引擎在这里调用了一个StaticTick
这样看得出来UE4的有些资源是runtime进行异步加载的,而且是逐tick
(可能猜想有些资源是逐world或者逐level更新,但还没有注意到相关内容)
WorldTick:
worldtick是GameEngine::Tick的主体之一,我们的逐level和分组tick都在里面完成
我们除了对每个world都做了tick之前还对他们做TickWorldTravel(Context, DeltaSeconds)来处理关卡加载的逻辑
Level的分组tick之前
首先是FDrawEvent* TickDrawEvent = BeginTickDrawEvent();
BeginTickDrawEvent()的构造里有ENQUEUE_RENDER_COMMAND(BeginDrawEventCommand)
向渲染队列发送一个BeginDrawEventCommand的命令,之后在DrawViewport阶段还会再塞一个BeginDrawingCommand
这种很相似的命名让我目前还没能分出这俩的功能差异
这个TickDrawEvent 会在最后再被调用一次,给渲染队列发送一个EndDrawEventCommand
首先会发送一个广播告知世界开始tick
FWorldDelegates::OnWorldTickStart.Broadcast(this, TickType, DeltaSeconds);
然后会更新我们的网络
BroadcastTickDispatch(DeltaSeconds);
BroadcastPostTickDispatch();
if( NetDriver && NetDriver->ServerConnection )
{
TickNetClient( DeltaSeconds );
}
接着验证是否是启用了高优先级的加载和无缝的切换地图,如果是就给异步加载更多时间
if (Info->bHighPriorityLoading || Info->bHighPriorityLoadingLocal || IsInSeamlessTravel())
{
CSV_SCOPED_SET_WAIT_STAT(AsyncLoading);
// Force it to use the entire time slice, even if blocked on I/O
ProcessAsyncLoading(true, true, GPriorityAsyncLoadingExtraTime / 1000.0f);
}
然后Tick我们的Nav导航系统:
if (NavigationSystem != nullptr)
{
NavigationSystem->Tick(DeltaSeconds);
}
Nav更新完以后会进行一个广播作为分组Tick开始的标志,在分组tick结束时也会进行一个广播
FWorldDelegates::OnWorldPreActorTick.Broadcast(this, TickType, DeltaSeconds);
....
FWorldDelegates::OnWorldPostActorTick.Broadcast(this, TickType, DeltaSeconds);
然后会遍历LevelCollection收集需要Tick的level假如到LevelsToTick,进行分组Tick
这取决于关卡是静态的还是动态的(一般我们所想可能是只会tick已加载的level)
Level的分组tick
分组tick如下:
for (int32 i = 0; i < LevelCollections.Num(); ++i) {
RunTickGroup(TG_PrePhysics);
RunTickGroup(TG_StartPhysics);
RunTickGroup(TG_DuringPhysics, false);
RunTickGroup(TG_EndPhysics);
RunTickGroup(TG_PostPhysics);
GetTimerManager().Tick(DeltaSeconds);
FTickableGameObject::TickObjects(this, TickType, bIsPaused, DeltaSeconds);
PlayerController->UpdateCameraManager(DeltaSeconds);
RunTickGroup(TG_PostUpdateWork);
RunTickGroup(TG_LastDemotable);
}
其实一般写Gameplay我们能够选择的分组主要就是四个:
TG_PrePhysics,TG_DuringPhysics,TG_PostPhysics,TG_PostUpdateWork
首先在PrePhysics开始之前就会StartFrame,进行模拟之前的工作,构建碰撞树
PrePhysics组:
是一帧的开始。UE4很多component和actor的tick都在这里执行
此 tick 中的物理模拟数据属于上一帧,因为这一帧的物理模拟还没有开始
TG_StartPhysics:
在此之前会EnsureCollisionTreeIsBuilt()检查是否构建完物理树
随后通知PhysX进行物理模拟
TG_DuringPhysics:
这个步骤和物理线程TG_StartPhysics组是并行的,不依赖物理的actor和component一般放这里tick
常见用途为更新物品栏画面或小地图显示。此处物理数据完全无关,或显示要求不精确,一帧延迟不会造成问题。
TG_EndPhysics:
通知PhysX停止物理模拟
TG_PostPhysics:
物理模拟已经完成,假如我们的actor或者component需要依赖物理(骨骼,布料),则一般放在这里tick
渲染此帧时所有物理对象是位于它们的最终位置。
GetTimerManager().Tick:
Timer是Unreal 的定时器调度机制, 服务对象为 Delegate
FTickableGameObject::TickObjects(this, TickType, bIsPaused, DeltaSeconds):
Tickable Ticking 服务对象为 C++ 类, 我们会让对象继承自 FTickableGameObject 基类
重载Tick和GetStatId函数来获得Tick的功能
实现是每次 Tick 后遍历 FTickableStatics 集合中的所有 Tickable 对象并执行
因此 Tickable 对象会在每一帧执行, 不能设置 Tick 的时间间隔
UpdateCamera:
PlayerController->UpdateCameraManager(DeltaSeconds)会在这里更新相机
TG_PostUpdateWork:
在摄像机更新后发生。如特效必须知晓摄像机朝向的准确位置,可将控制这些特效的 actor 放置于此。
这也可用于在帧中绝对最靠后运行的游戏逻辑,如解决格斗游戏中两个角色在同一帧中尝试抓住对方的情况。
其中每个分组任务之间是串行的,必须在执行上一阶段分组Tick完成之后(否则阻塞)
才能执行下一阶段的分组Tick任务
Actor的Tick蓝图版本是实现了ReceiveTick(DeltaSeconds)
而cpp是通过LatentActionManager.ProcessLatentActions(this, MyWorld->GetDeltaSeconds());
这个函数在FengineLoop一些地方也能看到
Pawn是包含了AController的,它的tick通过AController 的 AddPawnTickDependency来实现
其他想获得Tick功能继承Uobject的则是通过继承FTickableGameObject获得Tick功能
在完成世界内的分组tick后,会进行一次广播宣告世界内的Tick完成
FWorldDelegates::OnWorldPostActorTick.Broadcast(this, TickType, DeltaSeconds);
Level的分组tick之后
在进行完Level的分组之后会简单的进行两次广播来刷新网络
BroadcastTickFlush(RealDeltaSeconds);
BroadcastPostTickFlush(RealDeltaSeconds);
引擎会尝试GC(看来GC也是每帧都有),更新FX特效系统
最后执行EndTickDrawEvent往渲染队列塞一个EndDrawEventCommand
这样便于渲染线程知道卡在这里边的指令都是Uworld::Tick的
Tickable GameObjects Without World
前边tick Tickable的时候是Tick处于Uworld之内的,剩下的就会在这一步进行Tick
其实和Uworld内调用是同一个函数,只是参数Uworld设成了空指针
RedrawViewPort
RedrawViewPort(只谈EngineTick里面的部分)在游戏线程也是大头,
(我们的大头其实就是UWorld::Tick,DrawViewPort,DrawSlate)
分为渲染场景和渲染UI的部分,渲染UI的部分只会渲染一些Debug的UI信息例如stat
游戏UI采用的Slate的Tick层级实际上是和UworldTick在同一层级的)
不过我想实际上Game线程中这部分的Tick耗时主要还是做的线程调度和计算
渲染线程做culling、batching和渲染API的生成,RHI线程做渲染API的执行
首先进行GameViewport->Tick(DeltaSeconds)来tick,实际上是发送一个TickDelegate的广播
接着进入FViewport::Draw函数,进行一些截图数据的准备
准备完之后调用EnqueueBeginRenderFrame(bShouldPresent)
其中是ENQUEUE_RENDER_COMMAND(BeginDrawingCommand)发给渲染队列
接着调用ViewportClient->Draw(this, &Canvas)进行场景相关的计算
Draw函数有许多的继承,游戏调用的实际上是UgameViewportClient::Draw
有一句重要的语句决定了我们走的是什么样的渲染管线,以及之后的渲染细节
GetRendererModule().BeginRenderingViewFamily(SceneCanvas,&ViewFamily);
{
...
FSceneRenderer* SceneRenderer = FSceneRenderer::CreateSceneRenderer(ViewFamily, Canvas->GetHitProxyConsumer());
...
ENQUEUE_RENDER_COMMAND(FInitFXSystemCommand)
...
ENQUEUE_RENDER_COMMAND(FDrawSceneCommand)
...
}
这一句根据我们的ShadingPath决定走的是延迟渲染还是Mobile渲染(Mobile也可以做延迟渲染)
接着会将场景计算完成,打包成一个FDrawSceneCommand然后也发送给渲染线程
渲染场景的步骤完成后广播一次EndScene
跳出这一步后会把RT给clear掉然后继续渲染HUD,不过渲染与否居然是Slate决定的
// Clear areas of the rendertarget (backbuffer) that aren't drawn over by the views.
...
// Render the UI
if (FSlateApplication::Get().GetPlatformApplication()->IsAllowedToRender())
{
...
}
做完这些以后做一个广播,然后根据是否开启垂直同步进行一个渲染队列的入队操作
Canvas.Flush_GameThread();
UGameViewportClient::OnViewportRendered().Broadcast(this);
...
SetRequiresVsync(bLockToVsync);
EnqueueEndRenderFrame(bLockToVsync, bShouldPresent);
做完RedrawViewports的操作之后会通知渲染线程TickRenderingTimer,实际上所做的更新RT池的操作
这些以上就是ReDrawViewports的大部分主要操作,同时也是一套UGameEngine::tick的基本流程
全程的广播,函数嵌套,虚函数继承,多次往渲染队列添加命令让人感觉眼花缭乱
即使是大概看了一遍所有的流程依然还是觉得很晕,框架并没有像他的外层EngineLoop那么的简单明了
不过之后结合Render和RHI线程来理解再次加深分析应该还能再加深一点印象